damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

ShareViewController.swift (17635B)


      1 //
      2 //  ShareViewController.swift
      3 //  share extension
      4 //
      5 //  Created by Swift on 11/4/24.
      6 //
      7 
      8 import SwiftUI
      9 import Social
     10 import UniformTypeIdentifiers
     11 
     12 let this_app: UIApplication = UIApplication()
     13 
     14 class ShareViewController: SLComposeServiceViewController {
     15     private var contentView: UIHostingController<ShareExtensionView>?
     16     
     17     override func viewDidLoad() {
     18         super.viewDidLoad()
     19         self.view.tintColor = UIColor(DamusColors.purple)
     20         
     21         DispatchQueue.main.async {
     22             let contentView = UIHostingController(rootView: ShareExtensionView(extensionContext: self.extensionContext!,
     23                                                                                dismissParent: { [weak self] in
     24                 self?.dismissSelf()
     25             }
     26                                                                               ))
     27             self.addChild(contentView)
     28             self.contentView = contentView
     29             self.view.addSubview(contentView.view)
     30             
     31             // set up constraints
     32             contentView.view.translatesAutoresizingMaskIntoConstraints = false
     33             contentView.view.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true
     34             contentView.view.bottomAnchor.constraint (equalTo: self.view.bottomAnchor).isActive = true
     35             contentView.view.leftAnchor.constraint(equalTo: self.view.leftAnchor).isActive = true
     36             contentView.view.rightAnchor.constraint (equalTo: self.view.rightAnchor).isActive = true
     37         }
     38     }
     39     
     40     func dismissSelf() {
     41         super.didSelectCancel()
     42     }
     43 }
     44 
     45 struct ShareExtensionView: View {
     46     @State private var share_state: ShareState = .loading
     47     let extensionContext: NSExtensionContext
     48     @State private var state: DamusState? = nil
     49     @State private var preUploadedMedia: [PreUploadedMedia] = []
     50     var dismissParent: (() -> Void)?
     51     
     52     @Environment(\.scenePhase) var scenePhase
     53     
     54     var body: some View {
     55         VStack(spacing: 15) {
     56                 switch self.share_state {
     57                 case .loading:
     58                     ProgressView()
     59                 case .no_content:
     60                     Group {
     61                         Text("No content available to share", comment: "Title indicating that there was no available content to share")
     62                             .font(.largeTitle)
     63                             .multilineTextAlignment(.center)
     64                             .padding()
     65                         Text("There is no content available to share at this time. Please close this view and try again.", comment: "Label explaining that no content is available to share and instructing the user to close the view and try again.")
     66                             .multilineTextAlignment(.center)
     67                             .padding(.horizontal)
     68                         
     69                         Button(action: {
     70                             self.done()
     71                         }, label: {
     72                             Text("Close", comment: "Button label giving the user the option to close the view when no content is available to share")
     73                         })
     74                         .foregroundStyle(.secondary)
     75                     }
     76                 case .not_logged_in:
     77                     Group {
     78                         Text("Not Logged In", comment: "Title indicating that sharing cannot proceed because the user is not logged in.")
     79                             .font(.largeTitle)
     80                             .multilineTextAlignment(.center)
     81                             .padding()
     82                         
     83                         Text("You cannot share content because you are not logged in. Please close this view, log in to your account, and try again.", comment: "Label explaining that sharing cannot proceed because the user is not logged in.")
     84                             .multilineTextAlignment(.center)
     85                             .padding(.horizontal)
     86                         
     87                         Button(action: {
     88                             self.done()
     89                         }, label: {
     90                             Text("Close", comment: "Button label giving the user the option to close the sheet due to not being logged in.")
     91                         })
     92                         .foregroundStyle(.secondary)
     93                     }
     94                 case .loaded(let content):
     95                     PostView(
     96                         action: .sharing(content),
     97                         damus_state: state!  // state will have a value at this point
     98                     )
     99                 case .cancelled:
    100                     Group {
    101                         Text("Cancelled", comment: "Title indicating that the user has cancelled.")
    102                             .font(.largeTitle)
    103                             .padding()
    104                         Button(action: {
    105                             self.done()
    106                         }, label: {
    107                             Text("Close", comment: "Button label giving the user the option to close the sheet from which they were trying to share.")
    108                         })
    109                         .foregroundStyle(.secondary)
    110                     }
    111                 case .failed(let error):
    112                     Group {
    113                         Text("Error", comment: "Title indicating that an error has occurred.")
    114                             .font(.largeTitle)
    115                             .multilineTextAlignment(.center)
    116                             .padding()
    117                         Text("An unexpected error occurred. Please contact Damus support via [Nostr](damus:npub18m76awca3y37hkvuneavuw6pjj4525fw90necxmadrvjg0sdy6qsngq955) or [email](support@damus.io) with the error message below.", comment: "Label explaining there was an error, and suggesting next steps")
    118                             .multilineTextAlignment(.center)
    119                         Text("Error: \(error)")
    120                         Button(action: {
    121                             done()
    122                         }, label: {
    123                             Text("Close", comment: "Button label giving the user the option to close the sheet from which they were trying share.")
    124                         })
    125                         .foregroundStyle(.secondary)
    126                     }
    127                 case .posted(event: let event):
    128                     Group {
    129                         Image(systemName: "checkmark.circle.fill")
    130                             .resizable()
    131                             .frame(width: 60, height: 60)
    132                         Text("Shared", comment: "Title indicating that the user has shared content successfully")
    133                             .font(.largeTitle)
    134                             .multilineTextAlignment(.center)
    135                             .padding(.bottom)
    136                         
    137                         Link(destination: URL(string: "damus:\(event.id.bech32)")!, label: {
    138                             Text("Go to the app", comment: "Button label giving the user the option to go to the app after sharing content")
    139                         })
    140                         .buttonStyle(GradientButtonStyle())
    141                         
    142                         Button(action: {
    143                             self.done()
    144                         }, label: {
    145                             Text("Close", comment: "Button label giving the user the option to close the sheet from which they shared content")
    146                         })
    147                         .foregroundStyle(.secondary)
    148                     }
    149                 case .posting:
    150                     Group {
    151                         ProgressView()
    152                             .frame(width: 20, height: 20)
    153                         Text("Sharing", comment: "Title indicating that the content is being published to the network")
    154                             .font(.largeTitle)
    155                             .multilineTextAlignment(.center)
    156                             .padding(.bottom)
    157                         Text("Your content is being broadcasted to the network. Please wait.", comment: "Label explaining that their content sharing action is in progress")
    158                             .multilineTextAlignment(.center)
    159                             .padding()
    160                     }
    161                 }
    162         }
    163         .onAppear(perform: {
    164             if setDamusState() {
    165                 self.loadSharedContent()
    166             }
    167         })
    168         .onDisappear {
    169             Task { @MainActor in
    170                 self.state?.ndb.close()
    171             }
    172         }
    173         .onReceive(handle_notify(.post)) { post_notification in
    174             switch post_notification {
    175             case .post(let post):
    176                 self.post(post)
    177             case .cancel:
    178                 self.share_state = .cancelled
    179                 dismissParent?()
    180             }
    181         }
    182         .onChange(of: scenePhase) { (phase: ScenePhase) in
    183             guard let state else { return }
    184             switch phase {
    185             case .background:
    186                 print("txn: 📙 SHARE BACKGROUNDED")
    187                 Task { @MainActor in
    188                     state.ndb.close()
    189                 }
    190                 break
    191             case .inactive:
    192                 print("txn: 📙 SHARE INACTIVE")
    193                 break
    194             case .active:
    195                 print("txn: 📙 SHARE ACTIVE")
    196                 state.pool.ping()
    197             @unknown default:
    198                 break
    199             }
    200         }
    201         .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { obj in
    202             guard let state else { return }
    203             print("SHARE ACTIVE NOTIFY")
    204             if state.ndb.reopen() {
    205                 print("SHARE NOSTRDB REOPENED")
    206             } else {
    207                 print(" SHARE NOSTRDB FAILED TO REOPEN closed: \(state.ndb.is_closed)")
    208             }
    209         }
    210         .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { obj in
    211             guard let state else { return }
    212             print("txn: 📙 SHARE BACKGROUNDED")
    213             Task { @MainActor in
    214                 state.ndb.close()
    215             }
    216         }
    217     }
    218     
    219     func post(_ post: NostrPost) {
    220         self.share_state = .posting
    221         guard let state else {
    222             self.share_state = .failed(error: "Damus state not initialized")
    223             return
    224         }
    225         guard let full_keypair = state.keypair.to_full() else {
    226             self.share_state = .not_logged_in
    227             return
    228         }
    229         guard let posted_event = post.to_event(keypair: full_keypair) else {
    230             self.share_state = .failed(error: "Cannot convert post data into a nostr event")
    231             return
    232         }
    233         state.postbox.send(posted_event, on_flush: .once({ flushed_event in
    234             if flushed_event.event.id == posted_event.id {
    235                 DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: {  // Offset labor perception bias
    236                     self.share_state = .posted(event: flushed_event.event)
    237                 })
    238             }
    239             else {
    240                 self.share_state = .failed(error: "Flushed event is not the event we just tried to post.")
    241             }
    242         }))
    243     }
    244     
    245     @discardableResult
    246     private func setDamusState() -> Bool {
    247         guard let keypair = get_saved_keypair(),
    248               keypair.privkey != nil else {
    249             self.share_state = .not_logged_in
    250             return false
    251         }
    252         state = DamusState(keypair: keypair)
    253         return true
    254     }
    255     
    256     func loadSharedContent() {
    257         guard let extensionItem = extensionContext.inputItems.first as? NSExtensionItem else {
    258             share_state = .failed(error: "Unable to get item provider")
    259             return
    260         }
    261         
    262         var title = ""
    263         
    264         // Check for the attributed text from the extension item
    265         if let attributedContentData = extensionItem.userInfo?[NSExtensionItemAttributedContentTextKey] as? Data {
    266             if let attributedText = try? NSAttributedString(data: attributedContentData, options: [.documentType: NSAttributedString.DocumentType.rtf], documentAttributes: nil) {
    267                 let plainText = attributedText.string
    268                 print("Extracted Text: \(plainText)")
    269                 title = plainText
    270             } else {
    271                 print("Failed to decode RTF content.")
    272             }
    273         } else {
    274             print("Content is not in RTF format or data is unavailable.")
    275         }
    276         
    277         // Iterate through all attachments to handle multiple images
    278         for itemProvider in extensionItem.attachments ?? [] {
    279             if itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
    280                 itemProvider.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { (item, error) in
    281                     if let url = item as? URL {
    282                         
    283                         attemptAcquireResourceAndChooseMedia(
    284                             url: url,
    285                             fallback: processImage,
    286                             unprocessedEnum: {.unprocessed_image($0)},
    287                             processedEnum: {.processed_image($0)})
    288                         
    289                     } else if let image = item as? UIImage {
    290                         // process it directly if shared item is uiimage (example: image shared from Facebook, Signal apps)
    291                         chooseMedia(PreUploadedMedia.uiimage(image))
    292                     } else {
    293                         self.share_state = .failed(error: "Failed to load image content")
    294                     }
    295                 }
    296             } else if itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
    297                 itemProvider.loadItem(forTypeIdentifier: UTType.movie.identifier) { (item, error) in
    298                     if let url = item as? URL {
    299                         attemptAcquireResourceAndChooseMedia(
    300                             url: url,
    301                             fallback: processVideo,
    302                             unprocessedEnum: {.unprocessed_video($0)},
    303                             processedEnum: {.processed_video($0)}
    304                         )
    305                         
    306                     } else {
    307                         self.share_state = .failed(error: "Failed to load video content")
    308                     }
    309                 }
    310             } else if itemProvider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
    311                 itemProvider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { (item, error) in
    312                     // Sharing URLs from iPhone/Safari to Damus also follows this pathway
    313                     // Sharing Photos or Links from macOS/Finder or macOS/Safari to Damus sets item-provider conforming to UTType.url.identifier and therefore takes this pathway
    314                     
    315                     if let url = item as? URL {
    316                         // Sharing Photos from macOS/Finder
    317                         if url.absoluteString.hasPrefix("file:///") {
    318                             attemptAcquireResourceAndChooseMedia(
    319                                 url: url,
    320                                 fallback: processImage,
    321                                 unprocessedEnum: {.unprocessed_image($0)},
    322                                 processedEnum: {.processed_image($0)})
    323                             
    324                         } else {
    325                             // Sharing URLs from iPhone/Safari to Damus
    326                             self.share_state = .loaded(ShareContent(title: title, content: .link(url)))
    327                         }
    328                     } else if let data = item as? Data,
    329                               let string = String(data: data, encoding: .utf8),
    330                               let url = URL(string: string)  {
    331                             // Sharing Links from macOS/Safari, does not provide title
    332                             self.share_state = .loaded(ShareContent(title: "", content: .link(url)))
    333                     } else {
    334                         self.share_state = .failed(error: "Failed to load text content")
    335                     }
    336                 }
    337             } else {
    338                 share_state = .no_content
    339             }
    340         }
    341         
    342         func attemptAcquireResourceAndChooseMedia(url: URL, fallback: (URL) -> URL?, unprocessedEnum: (URL) -> PreUploadedMedia, processedEnum: (URL) -> PreUploadedMedia) {
    343             if url.startAccessingSecurityScopedResource() {
    344                 // Have permission from system to use url out of scope
    345                 print("Acquired permission to security scoped resource")
    346                 chooseMedia(unprocessedEnum(url))
    347             } else {
    348                 // Need to copy URL to non-security scoped location
    349                 guard let newUrl = fallback(url) else { return }
    350                 chooseMedia(processedEnum(newUrl))
    351             }
    352         }
    353         
    354         func chooseMedia(_ media: PreUploadedMedia) {
    355             self.preUploadedMedia.append(media)
    356             if extensionItem.attachments?.count == preUploadedMedia.count {
    357                 self.share_state = .loaded(ShareContent(title: "", content: .media(preUploadedMedia)))
    358             }
    359         }
    360     }
    361     
    362     private func done() {
    363         extensionContext.completeRequest(returningItems: [], completionHandler: nil)
    364     }
    365     
    366     private enum ShareState {
    367         case loading
    368         case no_content
    369         case not_logged_in
    370         case loaded(ShareContent)
    371         case failed(error: String)
    372         case cancelled
    373         case posting
    374         case posted(event: NostrEvent)
    375     }
    376 }
    377